/*
 * Copyright (C) 2012 Square, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package cn.nexd.app.okhttp.internal.http;

import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.SocketAddress;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.NoSuchElementException;

import cn.nexd.app.okhttp.Address;
import cn.nexd.app.okhttp.HttpUrl;
import cn.nexd.app.okhttp.Route;
import cn.nexd.app.okhttp.internal.RouteDatabase;

/**
 * Selects routes to connect to an origin server. Each connection requires a choice of proxy server,
 * IP address, and TLS mode. Connections may also be recycled.
 */
public final class RouteSelector {
    private final Address address;
    private final RouteDatabase routeDatabase;

    /* The most recently attempted route. */
    private Proxy lastProxy;
    private InetSocketAddress lastInetSocketAddress;

    /* State for negotiating the next proxy to use. */
    private List<Proxy> proxies = Collections.emptyList();
    private int nextProxyIndex;

    /* State for negotiating the next socket address to use. */
    private List<InetSocketAddress> inetSocketAddresses = Collections.emptyList();
    private int nextInetSocketAddressIndex;

    /* State for negotiating failed routes */
    private final List<Route> postponedRoutes = new ArrayList<Route>();

    public RouteSelector(Address address, RouteDatabase routeDatabase) {
        this.address = address;
        this.routeDatabase = routeDatabase;

        resetNextProxy(address.url(), address.proxy());
    }

    /**
     * Returns true if there's another route to attempt. Every address has at least one route.
     */
    public boolean hasNext() {
        return hasNextInetSocketAddress()
                || hasNextProxy()
                || hasNextPostponed();
    }

    public Route next() throws IOException {
        // Compute the next route to attempt.
        if (!hasNextInetSocketAddress()) {
            if (!hasNextProxy()) {
                if (!hasNextPostponed()) {
                    throw new NoSuchElementException();
                }
                return nextPostponed();
            }
            lastProxy = nextProxy();
        }
        lastInetSocketAddress = nextInetSocketAddress();

        Route route = new Route(address, lastProxy, lastInetSocketAddress);
        if (routeDatabase.shouldPostpone(route)) {
            postponedRoutes.add(route);
            // We will only recurse in order to skip previously failed routes. They will be tried last.
            return next();
        }

        return route;
    }

    /**
     * Clients should invoke this method when they encounter a connectivity failure on a connection
     * returned by this route selector.
     */
    public void connectFailed(Route failedRoute, IOException failure) {
        if (failedRoute.proxy().type() != Proxy.Type.DIRECT && address.proxySelector() != null) {
            // Tell the proxy selector when we fail to connect on a fresh connection.
            address.proxySelector().connectFailed(
                    address.url().uri(), failedRoute.proxy().address(), failure);
        }

        routeDatabase.failed(failedRoute);
    }

    /**
     * Prepares the proxy servers to try.
     */
    private void resetNextProxy(HttpUrl url, Proxy proxy) {
        if (proxy != null) {
            // If the user specifies a proxy, try that and only that.
            proxies = Collections.singletonList(proxy);
        } else {
            // Try each of the ProxySelector choices until one connection succeeds. If none succeed
            // then we'll try a direct connection below.
            proxies = new ArrayList<Proxy>();
            List<Proxy> selectedProxies = address.proxySelector().select(url.uri());
            if (selectedProxies != null) proxies.addAll(selectedProxies);
            // Finally try a direct connection. We only try it once!
            proxies.removeAll(Collections.singleton(Proxy.NO_PROXY));
            proxies.add(Proxy.NO_PROXY);
        }
        nextProxyIndex = 0;
    }

    /**
     * Returns true if there's another proxy to try.
     */
    private boolean hasNextProxy() {
        return nextProxyIndex < proxies.size();
    }

    /**
     * Returns the next proxy to try. May be PROXY.NO_PROXY but never null.
     */
    private Proxy nextProxy() throws IOException {
        if (!hasNextProxy()) {
            throw new SocketException("No route to " + address.url().host()
                    + "; exhausted proxy configurations: " + proxies);
        }
        Proxy result = proxies.get(nextProxyIndex++);
        resetNextInetSocketAddress(result);
        return result;
    }

    /**
     * Prepares the socket addresses to attempt for the current proxy or host.
     */
    private void resetNextInetSocketAddress(Proxy proxy) throws IOException {
        // Clear the addresses. Necessary if getAllByName() below throws!
        inetSocketAddresses = new ArrayList<InetSocketAddress>();

        String socketHost;
        int socketPort;
        if (proxy.type() == Proxy.Type.DIRECT || proxy.type() == Proxy.Type.SOCKS) {
            socketHost = address.url().host();
            socketPort = address.url().port();
        } else {
            SocketAddress proxyAddress = proxy.address();
            if (!(proxyAddress instanceof InetSocketAddress)) {
                throw new IllegalArgumentException(
                        "Proxy.address() is not an " + "InetSocketAddress: " + proxyAddress.getClass());
            }
            InetSocketAddress proxySocketAddress = (InetSocketAddress) proxyAddress;
            socketHost = getHostString(proxySocketAddress);
            socketPort = proxySocketAddress.getPort();
        }

        if (socketPort < 1 || socketPort > 65535) {
            throw new SocketException("No route to " + socketHost + ":" + socketPort
                    + "; port is out of range");
        }

        if (proxy.type() == Proxy.Type.SOCKS) {
            inetSocketAddresses.add(InetSocketAddress.createUnresolved(socketHost, socketPort));
        } else {
            // Try each address for best behavior in mixed IPv4/IPv6 environments.
            List<InetAddress> addresses = address.dns().lookup(socketHost);
            for (int i = 0, size = addresses.size(); i < size; i++) {
                InetAddress inetAddress = addresses.get(i);
                inetSocketAddresses.add(new InetSocketAddress(inetAddress, socketPort));
            }
        }

        nextInetSocketAddressIndex = 0;
    }

    /**
     * Obtain a "host" from an {@link InetSocketAddress}. This returns a string containing either an
     * actual host name or a numeric IP address.
     */
    // Visible for testing
    static String getHostString(InetSocketAddress socketAddress) {
        InetAddress address = socketAddress.getAddress();
        if (address == null) {
            // The InetSocketAddress was specified with a string (either a numeric IP or a host name). If
            // it is a name, all IPs for that name should be tried. If it is an IP address, only that IP
            // address should be tried.
            return socketAddress.getHostName();
        }
        // The InetSocketAddress has a specific address: we should only try that address. Therefore we
        // return the address and ignore any host name that may be available.
        return address.getHostAddress();
    }

    /**
     * Returns true if there's another socket address to try.
     */
    private boolean hasNextInetSocketAddress() {
        return nextInetSocketAddressIndex < inetSocketAddresses.size();
    }

    /**
     * Returns the next socket address to try.
     */
    private InetSocketAddress nextInetSocketAddress() throws IOException {
        if (!hasNextInetSocketAddress()) {
            throw new SocketException("No route to " + address.url().host()
                    + "; exhausted inet socket addresses: " + inetSocketAddresses);
        }
        return inetSocketAddresses.get(nextInetSocketAddressIndex++);
    }

    /**
     * Returns true if there is another postponed route to try.
     */
    private boolean hasNextPostponed() {
        return !postponedRoutes.isEmpty();
    }

    /**
     * Returns the next postponed route to try.
     */
    private Route nextPostponed() {
        return postponedRoutes.remove(0);
    }
}
